Skip to content

Add lease transaction statistics - #22

Open
pkaminski wants to merge 5 commits into
masterfrom
codex/add-lease-transaction-stats
Open

Add lease transaction statistics#22
pkaminski wants to merge 5 commits into
masterfrom
codex/add-lease-transaction-stats

Conversation

@pkaminski

@pkaminski pkaminski commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

  • track lifetime acquired, contended, and failed task lease transactions, including NodeFire tries metadata
  • keep a source-level exponential moving average of NodeFire transaction duration with alpha 0.1
  • expose additive count rollups and unweighted duration averages across active underlying sources and queues
  • add a synchronous per-queue captureLeaseTransactionMetrics(outcome, tries, duration) callback for acquired, contended, and failed transactions
  • expose a worker-local, non-enumerable _lease.firstAcquisition flag on a task's first acquisition
  • keep metric recording failures, missing metadata, and error-reporter failures from interrupting task processing
  • preserve the lifetime-cumulative tasksAcquired field and bump the package to 4.2.0

Motivation

Running several Firelease instances can amplify Firebase transaction load when they race to acquire the same task. The lifetime counters and duration moving average quantify that amplification without changing lease behavior, while the callback lets parent applications publish per-attempt StatsD or Sentry metrics. The first-acquisition flag lets parent workers distinguish newly queued work from retries without persisting instrumentation state.

Validation

  • yarn test (12 tests passed)
  • yarn lint --max-warnings=0
  • yarn check-types
  • yarn pack --dry-run
  • git diff --check

This change is Reviewable

@pkaminski
pkaminski force-pushed the codex/add-lease-transaction-stats branch from b36b916 to a375ed4 Compare August 5, 2026 23:20
@pkaminski
pkaminski force-pushed the codex/add-lease-transaction-stats branch from a375ed4 to a501e92 Compare August 5, 2026 23:31
@pkaminski
pkaminski force-pushed the codex/add-lease-transaction-stats branch from 185484b to d231c16 Compare August 6, 2026 00:35
@pkaminski
pkaminski force-pushed the codex/add-lease-transaction-stats branch from d231c16 to 7ba674c Compare August 6, 2026 00:41
@pkaminski
pkaminski marked this pull request as ready for review August 6, 2026 00:44

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread src/firelease.ts
Comment on lines +313 to +314
this.recordLeaseTransaction('acquired', transactionPromise.transaction);
if (firstAcquisition) Object.defineProperty(item._lease, 'firstAcquisition', {value: true});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Safety check that a leased task is a real object was accidentally deleted

The guard that verified an acquired task is a proper object was replaced (by the Object.defineProperty call at src/firelease.ts:314) instead of being kept alongside it, so a malformed task value now reaches later code and fails with a confusing internal error.
Impact: When a task's value is not an object (e.g. a preprocess function that returns a non-object), the operator sees an obscure type error instead of the clear "item not an object" diagnostic, and the bad value may be handed further down the pipeline.

Mechanism: replaced assertion in the acquired branch of Task.process

Before this PR the acquired branch read:

if (!_.isObject(item)) throw new Error(`item not an object: ${item}`);
this.queue.stats.tasksAcquired++;
await this.run(item as WorkerItem, startTimestamp);

In commit 3794364 that line was overwritten with the firstAcquisition definition rather than a new line being added (src/firelease.ts:312-316). If Queue.callPreprocess (src/firelease.ts:1079-1082) returns a non-object, the transaction now resolves with a primitive: item._lease is undefined, so Object.defineProperty(undefined, ...) throws a TypeError ("Cannot convert undefined or null to object"), which is swallowed by the generic leasing catch and reported as a lease transaction error. If firstAcquisition happened to be false the primitive would be passed straight into run(), where Object.defineProperty(item, '$ref', ...) fails instead.

Suggested change
this.recordLeaseTransaction('acquired', transactionPromise.transaction);
if (firstAcquisition) Object.defineProperty(item._lease, 'firstAcquisition', {value: true});
this.recordLeaseTransaction('acquired', transactionPromise.transaction);
if (!_.isObject(item)) throw new Error(`item not an object: ${item}`);
if (firstAcquisition) Object.defineProperty(item._lease, 'firstAcquisition', {value: true});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has not triggered in living memory so I think it's fine to leave the case to an internal error. Keeping the check there messed up the types for the defineProperty line.

Comment thread src/stats.ts Outdated
Comment on lines +24 to +27
const attempts = _.sumBy(items, countLeaseAttempts);
if (attempts) {
result.duration = _.sumBy(items, item => item.duration * countLeaseAttempts(item)) / attempts;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Attempt-weighted duration rollups become effectively frozen over process lifetime

Per-source duration is a fast-moving exponential moving average (alpha 0.1) of recent transactions, but the rollup weights each source by its lifetime attempt count (countLeaseAttempts at src/stats.ts:31-33). Since counts are now lifetime-cumulative and never reset, a source that processed a huge volume early will permanently dominate the queue/global duration figure even while idle, and a newly busy source's latency will barely move the aggregate. Combining a recency-weighted per-source metric with lifetime-count weighting is arguably inconsistent; weighting by recent attempts (or a simple unweighted mean) would track current behavior more faithfully.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with an unweighted mean @pkaminski+CODX.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the queue/global duration rollups to use an unweighted mean across underlying sources or queues that have recorded at least one lease attempt. Never-used sources are excluded so their initial zero does not skew the result. I updated the hierarchy tests and documentation accordingly.

Comment thread src/firelease.ts
Comment on lines +321 to +323
if (!transactionCompleted && this.ref.key !== PING_KEY) {
this.recordLeaseTransaction('failed', transactionPromise.transaction);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Metric recording inside the failure handler can escape and leak a concurrency slot

recordLeaseTransaction is now invoked from inside the leasing catch block. Its callback invocation is guarded, but the fallback settings.captureError inside that guard is user-supplied and unguarded; if it throws, the exception escapes Task.process() while this.working is still true and before the phase/reschedule bookkeeping runs. Queue.process (src/firelease.ts:1054-1076) catches the error only after globalNumConcurrent--/this.numConcurrent-- have been skipped, permanently leaking a concurrency slot and leaving the task stuck as working. Same exposure exists if transactionPromise.transaction is ever undefined on a rejected NodeFire transaction. Consider wrapping the whole recordLeaseTransaction body defensively.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the entire metric-recording path defensive. Missing NodeFire transaction metadata now falls back to zero, and errors from recording or invoking the metric callback are reported through a nested guard so even a throwing settings.captureError cannot escape into task processing. An integration test covers missing metadata, a throwing callback, and a throwing error reporter while verifying that the worker still completes.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7ba674cf1d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/firelease.ts
return item;
}
acquired = true;
firstAcquisition = _.isNil(item._lease?.initial);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve acquisition history across custom lease returns

When a worker retries by returning an allowed complete Lease object without initial (for example, {expiry: retryAt}), post-processing replaces _lease and drops the previous marker. On the next lease this check evaluates true, so _lease.firstAcquisition is exposed again even though the task has already run, causing workers that use the new flag to repeat first-run behavior.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no way to preserve this history without persisting it and I'd rather not add another field at this time. It doesn't matter for our current usage since we only use firstAcquisition in conjunction with created, and the latter gets cleared or appropriately reset whenever initial is.

Comment thread src/firelease.ts
leaseStats.duration * (1 - LEASE_TRANSACTION_DURATION_ALPHA) +
transactionDuration * LEASE_TRANSACTION_DURATION_ALPHA;
try {
this.queue.options.captureLeaseTransactionMetrics?.(outcome, tries, transactionDuration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle rejected metric callback promises

When captureLeaseTransactionMetrics is implemented as an async callback and rejects, this synchronous try/catch does not observe the rejection, so settings.captureError is never called and Node may treat it as an unhandled rejection and terminate the process. Async functions are assignable to this void-returning callback type, and the callback is not documented as synchronous, so its returned thenable should be handled while keeping task processing independent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's adjust the callback type to exclude Promise return types @pkaminski+CODX.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened CaptureLeaseTransactionMetrics to return undefined instead of void, which prevents async or other value-returning functions from satisfying the callback type. I also documented that the callback must be synchronous and added a negative TypeScript test for an async callback.

@pkaminski pkaminski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski made 4 comments and resolved 2 discussions.
Reviewable status: 0 of 9 files reviewed, 3 unresolved discussions (waiting on pkaminski+CODX).

Comment thread src/firelease.ts
return item;
}
acquired = true;
firstAcquisition = _.isNil(item._lease?.initial);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no way to preserve this history without persisting it and I'd rather not add another field at this time. It doesn't matter for our current usage since we only use firstAcquisition in conjunction with created, and the latter gets cleared or appropriately reset whenever initial is.

Comment thread src/firelease.ts
Comment on lines +313 to +314
this.recordLeaseTransaction('acquired', transactionPromise.transaction);
if (firstAcquisition) Object.defineProperty(item._lease, 'firstAcquisition', {value: true});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has not triggered in living memory so I think it's fine to leave the case to an internal error. Keeping the check there messed up the types for the defineProperty line.

Comment thread src/firelease.ts
leaseStats.duration * (1 - LEASE_TRANSACTION_DURATION_ALPHA) +
transactionDuration * LEASE_TRANSACTION_DURATION_ALPHA;
try {
this.queue.options.captureLeaseTransactionMetrics?.(outcome, tries, transactionDuration);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's adjust the callback type to exclude Promise return types @pkaminski+CODX.

Comment thread src/stats.ts Outdated
Comment on lines +24 to +27
const attempts = _.sumBy(items, countLeaseAttempts);
if (attempts) {
result.duration = _.sumBy(items, item => item.duration * countLeaseAttempts(item)) / attempts;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go with an unweighted mean @pkaminski+CODX.

@pkaminski pkaminski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pkaminski+CODX made 3 comments and resolved 3 discussions.
Reviewable status: 0 of 9 files reviewed, all discussions resolved.

Comment thread src/firelease.ts
Comment on lines +321 to +323
if (!transactionCompleted && this.ref.key !== PING_KEY) {
this.recordLeaseTransaction('failed', transactionPromise.transaction);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the entire metric-recording path defensive. Missing NodeFire transaction metadata now falls back to zero, and errors from recording or invoking the metric callback are reported through a nested guard so even a throwing settings.captureError cannot escape into task processing. An integration test covers missing metadata, a throwing callback, and a throwing error reporter while verifying that the worker still completes.

Comment thread src/firelease.ts
leaseStats.duration * (1 - LEASE_TRANSACTION_DURATION_ALPHA) +
transactionDuration * LEASE_TRANSACTION_DURATION_ALPHA;
try {
this.queue.options.captureLeaseTransactionMetrics?.(outcome, tries, transactionDuration);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tightened CaptureLeaseTransactionMetrics to return undefined instead of void, which prevents async or other value-returning functions from satisfying the callback type. I also documented that the callback must be synchronous and added a negative TypeScript test for an async callback.

Comment thread src/stats.ts Outdated
Comment on lines +24 to +27
const attempts = _.sumBy(items, countLeaseAttempts);
if (attempts) {
result.duration = _.sumBy(items, item => item.duration * countLeaseAttempts(item)) / attempts;
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed the queue/global duration rollups to use an unweighted mean across underlying sources or queues that have recorded at least one lease attempt. Never-used sources are excluded so their initial zero does not skew the result. I updated the hierarchy tests and documentation accordingly.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c30fa81bf4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/firelease.ts
Comment on lines +933 to 935
const latency = Math.round(performance.now() - startedAt);
this.stats.latency = latency;
this.stats.healthy = succeeded && latency < this.queue.options.healthyPingLatency;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare the unrounded ping latency with the health limit

When a successful ping finishes just below an integer healthyPingLatency threshold—for example, 1499.6 ms with the default 1500 ms limit—rounding first produces 1500, so the strict comparison incorrectly marks the source and queue unhealthy. Keep the precise elapsed value for the health comparison, even if the latency exposed in stats should be rounded.

Useful? React with 👍 / 👎.

@pkaminski
pkaminski requested a review from snoack August 6, 2026 01:19

@pkaminski pkaminski left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+r:@snoack

@pkaminski made 1 comment and resolved 1 discussion.
Reviewable status: 0 of 9 files reviewed, all discussions resolved (waiting on snoack).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant